Рассмотрим коллекцию новостных сообщений за первую половину 2017 года. Про каждое новостное сообщение известны:
import pandas as pd
df = pd.read_csv('../data/news.csv')
df.head()
| text | date | event | class | |
|---|---|---|---|---|
| 0 | В ПЕТЕРБУРГЕ ПРОШЕЛ МИТИНГ ПРОТИВ ПЕРЕДАЧИ ИС... | 2017-01-10 | Передача РПЦ Исаакиевского собора | Внутренняя политика РФ |
| 1 | Lenta.co, Москва, 14 января 2017 СИТУАЦИЯ С П... | 2017-01-10 | Передача РПЦ Исаакиевского собора | Внутренняя политика РФ |
| 2 | Аргументы и Факты (aif.ru), Москва, 14 января... | 2017-01-10 | Передача РПЦ Исаакиевского собора | Внутренняя политика РФ |
| 3 | Google Новости ТОП, Москва, 14 января 2017 АК... | 2017-01-10 | Передача РПЦ Исаакиевского собора | Внутренняя политика РФ |
| 4 | Газета.Ru, Москва, 13 января 2017 В МОСКОВСКО... | 2017-01-10 | Передача РПЦ Исаакиевского собора | Внутренняя политика РФ |
len_data = df.text.apply(len)
len_data.describe()
count 1930.000000 mean 3798.322798 std 7865.936695 min 31.000000 25% 1215.250000 50% 1918.000000 75% 4044.000000 max 185698.000000 Name: text, dtype: float64
from bokeh.charts import Bar, output_notebook, show, hplot
import math
output_notebook()
from bokeh.charts import Histogram
hist = Histogram(len_data[len_data < 10000])
show(hist)
Используем регулярные выражения, чтобы разбить тексты на слова
import re
regex = re.compile("[А-Яа-я----]+")
def words_only(text, regex=regex):
return " ".join(regex.findall(text))
df.text = df.text.str.lower()
df.text = df.text.apply(words_only)
Результат:
df.text.iloc[0]
'в петербурге прошел митинг против передачи исаакиевского собора рпц в санкт-петербурге люди устроили акцию протеста против передачи исаакиевского собора в безвозмездное пользование рпц жители петербурга собрались на исаакиевской площади чтобы высказаться против передачи исаакиевского собора в безвозмездное пользование рпц передает тасс акция проходит в формате встречи с депутатами законодательного собрания города и не требует согласования с властями участники акции не используют какую-либо символику и плакаты а также мегафоны или средства звукоусиления по словам депутата алексея ковалева на исаакиевскую площадь пришло примерно тысяча человек перед участниками протеста выступили депутаты местного парламента борис вишневский и максим резник которые заявили о том что потребуют отмены решения смольного вишневский сообщил что акция будет проходить в виде встречи депутатов с избирателями закон санкт-петербурга предоставляет нам право встречаться с избирателями такую встречу мы и проведем расскажем как защищаем их интересы при передаче собора - сказал парламентарий в свою очередь директор музея исаакиевский собор николай буров проинформировал что собор в пятницу будет закрыт намного раньше в связи с акцией протеста он подчеркнул что необходимо избежать стычек между сторонниками передачи собора и ее противниками ранее стало известно что собор передадут в безвозмездное пользование на лет русской православной церкви в лице московского патриархата при этом он останется в собственности петербурга тем временем в петербурге продолжается сбор подписей под петицией об отмене данного решения под документом уже поставили подписи более тысяч человек комментарии другие интересные статьи - - - - - - - - -'
from nltk import FreqDist
n_types = []
n_tokens = []
tokens = []
fd = FreqDist()
for index, row in df.iterrows():
tokens = row['text'].split()
fd.update(tokens)
n_types.append(len(fd))
n_tokens.append(sum(fd.values()))
for i in fd.most_common(10):
print(i)
('в', 43560)
('и', 25171)
('-', 24758)
('на', 19090)
('что', 13411)
('не', 11952)
('с', 10867)
('по', 8887)
('о', 5031)
('это', 4951)
from bokeh.plotting import figure
freqs = list(fd.values())
freqs = sorted(freqs, reverse = True)
p = figure(plot_width=500, plot_height=300)
p.line(freqs[:300], range(300))
show(p)
from bokeh.plotting import figure
freqs = list(fd.values())
freqs = sorted(freqs, reverse = True)
p = figure(plot_width=500, plot_height=300)
p.line(n_types, n_tokens)
show(p)
from nltk.corpus import stopwords
mystopwords = stopwords.words('russian') + ['это', 'наш' , 'тыс', 'млн', 'млрд', 'также', 'т', 'д', '-', '-']
print(mystopwords)
def remove_stopwords(text, mystopwords = mystopwords):
try:
return " ".join([token for token in text.split() if not token in mystopwords])
except:
return ""
df.text = df.text.apply(remove_stopwords)
['и', 'в', 'во', 'не', 'что', 'он', 'на', 'я', 'с', 'со', 'как', 'а', 'то', 'все', 'она', 'так', 'его', 'но', 'да', 'ты', 'к', 'у', 'же', 'вы', 'за', 'бы', 'по', 'только', 'ее', 'мне', 'было', 'вот', 'от', 'меня', 'еще', 'нет', 'о', 'из', 'ему', 'теперь', 'когда', 'даже', 'ну', 'вдруг', 'ли', 'если', 'уже', 'или', 'ни', 'быть', 'был', 'него', 'до', 'вас', 'нибудь', 'опять', 'уж', 'вам', 'ведь', 'там', 'потом', 'себя', 'ничего', 'ей', 'может', 'они', 'тут', 'где', 'есть', 'надо', 'ней', 'для', 'мы', 'тебя', 'их', 'чем', 'была', 'сам', 'чтоб', 'без', 'будто', 'чего', 'раз', 'тоже', 'себе', 'под', 'будет', 'ж', 'тогда', 'кто', 'этот', 'того', 'потому', 'этого', 'какой', 'совсем', 'ним', 'здесь', 'этом', 'один', 'почти', 'мой', 'тем', 'чтобы', 'нее', 'сейчас', 'были', 'куда', 'зачем', 'всех', 'никогда', 'можно', 'при', 'наконец', 'два', 'об', 'другой', 'хоть', 'после', 'над', 'больше', 'тот', 'через', 'эти', 'нас', 'про', 'всего', 'них', 'какая', 'много', 'разве', 'три', 'эту', 'моя', 'впрочем', 'хорошо', 'свою', 'этой', 'перед', 'иногда', 'лучше', 'чуть', 'том', 'нельзя', 'такой', 'им', 'более', 'всегда', 'конечно', 'всю', 'между', 'это', 'наш', 'тыс', 'млн', 'млрд', 'также', 'т', 'д', '-', '-']
%%time
from pymystem3 import Mystem
m = Mystem()
def lemmatize(text, mystem=m):
try:
return "".join(m.lemmatize(text)).strip()
except:
return " "
df.text = df.text.apply(lemmatize)
CPU times: user 5.81 s, sys: 301 ms, total: 6.11 s Wall time: 48.5 s
mystoplemmas = ['который','прошлый','сей', 'свой', 'наш', 'мочь']
def remove_stoplemmas(text, mystoplemmas = mystoplemmas):
try:
return " ".join([token for token in text.split() if not token in mystoplemmas])
except:
return ""
df.text = df.text.apply(remove_stoplemmas)
Самые частые леммы:
lemmata = []
for index, row in df.iterrows():
lemmata += row['text'].split()
fd = FreqDist(lemmata)
for i in fd.most_common(10):
print(i)
('россия', 5607)
('год', 4750)
('москва', 4614)
('человек', 4550)
('путин', 4346)
('президент', 4017)
('выборы', 2842)
('вопрос', 2654)
('время', 2261)
('российский', 2250)
Переезжаем из DataFrame в списки:
tokens_by_topic = []
for event in df.event.unique():
tokens = []
sample = df[df.event==event]
for i in range(len(sample)):
tokens += sample.text.iloc[i].split()
tokens_by_topic.append(tokens)
Выберем событие, из текстов про которое будем извлекать ключевые слова:
event_id = 3
Извлекаем биграммы по разным мерам связности:
%%time
import nltk
from nltk.collocations import *
N_best = 100 # число извлекаемых биграм
bigram_measures = nltk.collocations.BigramAssocMeasures() # класс для мер ассоциации биграм
finder = BigramCollocationFinder.from_words(tokens_by_topic[event_id]) # класс для хранения и извлечения биграм
finder.apply_freq_filter(3) # избавимся от биграм, которые встречаются реже трех раз
raw_freq_ranking = [' '.join(i) for i in finder.nbest(bigram_measures.raw_freq, N_best)] # выбираем топ-10 биграм по частоте
tscore_ranking = [' '.join(i) for i in finder.nbest(bigram_measures.student_t, N_best)] # выбираем топ-100 биграм по каждой мере
pmi_ranking = [' '.join(i) for i in finder.nbest(bigram_measures.pmi, N_best)]
llr_ranking = [' '. join(i) for i in finder.nbest(bigram_measures.likelihood_ratio, N_best)]
chi2_ranking = [' '.join(i) for i in finder.nbest(bigram_measures.chi_sq, N_best)]
CPU times: user 255 ms, sys: 4.91 ms, total: 260 ms Wall time: 263 ms
Результаты:
rankings = pd.DataFrame({ 'chi2': chi2_ranking, 'llr':llr_ranking, 't-score' : tscore_ranking, 'pmi': pmi_ranking, 'raw_freq':raw_freq_ranking})
rankings = rankings[['raw_freq', 'pmi', 't-score', 'chi2', 'llr']]
rankings.head(10)
| raw_freq | pmi | t-score | chi2 | llr | |
|---|---|---|---|---|---|
| 0 | дмитрий медведев | анатолий афанасьевич | дмитрий медведев | алый парус | дмитрий медведев |
| 1 | фонд дар | артур саркисян | фонд дар | анатолий афанасьевич | фонд дар |
| 2 | миллиард рубль | атрибутика дореволюционный | миллиард рубль | арендный плата | илья елисеев |
| 3 | илья елисеев | афанасьевич младший | илья елисеев | артур саркисян | миллиард рубль |
| 4 | алексей навальный | взрывчатый токсичный | алексей навальный | атрибутика дореволюционный | борьба коррупция |
| 5 | расследование фбк | водный транспортный | борьба коррупция | афанасьевич младший | скалистый берег |
| 6 | борьба коррупция | военно-морской флот | расследование фбк | бадминтон увлечение | квадратный метр |
| 7 | фонд борьба | воспламеняющийся окислять | фонд борьба | безобидный комический | доверенный лицо |
| 8 | фонд поддержка | враг нападать | фонд поддержка | взрывчатый токсичный | алексей навальный |
| 9 | доверенный лицо | выращивание помидор | доверенный лицо | виноградарство субсидия | москва март |
Похожи ли списки биграм?
from scipy.stats import spearmanr
import seaborn as sns
%matplotlib inline
corr = spearmanr(rankings).correlation
sns.heatmap(corr, annot=True, xticklabels = list(rankings), yticklabels = list(rankings))
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages/scipy/stats/stats.py:253: RuntimeWarning: The input array could not be properly checked for nan values. nan values will be ignored. "values. nan values will be ignored.", RuntimeWarning)
<matplotlib.axes._subplots.AxesSubplot at 0x116521ba8>
Используем TextRank для извлечения ключевых слов:
%%time
from gensim.summarization import keywords
text = ' '.join(tokens_by_topic[event_id])
kw = keywords(text)
Using TensorFlow backend.
CPU times: user 40.8 s, sys: 10.8 s, total: 51.6 s Wall time: 54 s
Результаты:
rankings = pd.DataFrame({'Text Rank': kw.split('\n')})
rankings.head(10)
| Text Rank | |
|---|---|
| 0 | дмитрии медведев фото александр |
| 1 | навальныи собирать |
| 2 | описание проходить расследование |
| 3 | премьер россия |
| 4 | такои |
| 5 | самыи схема |
| 6 | этот |
| 7 | яхта |
| 8 | усадьба |
| 9 | работа фбк насколько |
Для RAKE нужны сырые тексты со стоп-словами:
raw_df = pd.read_csv('../data/news.csv')
raw_df.text = raw_df.text.str.lower()
raw_df.text = raw_df.text.apply(words_only)
raw_df.text = raw_df.text.apply(lemmatize)
text = ' '.join(raw_df[raw_df.event == raw_df.event.unique()[3]].text.tolist())
Результаты RAKE:
import RAKE
Rake = RAKE.Rake('../data/stopwords.txt')
kp = [i[0] for i in Rake.run(text) if len(i[0].split())<3 and len(i[0].split())>1 and i[1]>1 and i[0] != '- -']
rankings = pd.DataFrame({'RAKE': kp})
rankings.head(10)
| RAKE | |
|---|---|
| 0 | цертум-инвест цертум-инвест |
| 1 | коррумпированный-коррумпировать премьер-министр |
| 2 | кто-то фонд |
| 3 | интернет-магазин история |
| 4 | нынешний премьер-министр |
| 5 | где-то останавливаться |
| 6 | интерес премьер-министр |
| 7 | личный интернет-покупка |
| 8 | премьер-министр выбирать |
| 9 | интернет-покупка делать |
Извлекаем ключевые слова по $tf-idf$:
%%time
from nltk.text import TextCollection
tfidf_values = []
tfidf_ranking = []
corpus = TextCollection(tokens_by_topic) # класс для вычисления tf-idf
for i in set(tokens_by_topic[event_id]): # цикл по всем уникальным токенам в этом разделе
tfidf_values.append([i, corpus.tf_idf(i, tokens_by_topic[event_id])]) # вычисляем tf-idf
for i in sorted(tfidf_values,key=lambda l:l[1], reverse=True)[:N_best]: # выбираем топ-100 по tf-idf
tfidf_ranking.append(i[0])
CPU times: user 39.4 s, sys: 249 ms, total: 39.6 s Wall time: 40.4 s
Результаты:
rankings = pd.DataFrame({'tf-idf': tfidf_ranking})
rankings.head(10)
| tf-idf | |
|---|---|
| 0 | медведев |
| 1 | дар |
| 2 | елисеев |
| 3 | фбк |
| 4 | яхта |
| 5 | усадьба |
| 6 | виноградник |
| 7 | плес |
| 8 | агрокомплекс |
| 9 | усманов |
Представление данных в Gensim словарем и корпусом:
from gensim.corpora import *
texts = [df.text.iloc[i].split() for i in range(len(df))]
dictionary = Dictionary(texts)
corpus = [dictionary.doc2bow(text) for text in texts]
Вычисление сходства по косинусной мере на векторах $tf-idf$:
%%time
from gensim.models import *
tfidf = TfidfModel(corpus)
corpus_tfidf = tfidf[corpus]
CPU times: user 106 ms, sys: 2.8 ms, total: 109 ms Wall time: 109 ms
from gensim import similarities
index = similarities.MatrixSimilarity(tfidf[corpus])
sims = index[corpus_tfidf]
from pylab import pcolor, show, colorbar, xticks, yticks
import seaborn as sns
import matplotlib.pyplot as plt
%matplotlib inline
plt.figure(figsize = (10,10))
sns.heatmap(data=sims, cmap = 'Spectral').set(xticklabels=[],yticklabels=[])
plt.title("Матрица близости")
plt.show()
%%time
lsi = lsimodel.LsiModel(corpus=corpus_tfidf, id2word=dictionary, num_topics=50)
CPU times: user 2.52 s, sys: 184 ms, total: 2.71 s Wall time: 2.2 s
lsi.show_topics(5)
[(0, '0.224*"путин" + 0.164*"трамп" + 0.148*"меркель" + 0.134*"президент" + 0.127*"выборы" + 0.118*"курортный" + 0.113*"навальный" + 0.112*"акция" + 0.110*"теракт" + 0.105*"сбор"'), (1, '-0.550*"курортный" + -0.467*"сбор" + -0.235*"законопроект" + -0.212*"эксперимент" + -0.159*"инфраструктура" + -0.148*"введение" + -0.129*"вносить" + -0.119*"крым" + -0.119*"край" + -0.112*"алтайский"'), (2, '0.355*"собор" + 0.266*"исаакиевский" + -0.247*"путин" + -0.233*"меркель" + 0.205*"передача" + 0.203*"акция" + 0.203*"рпц" + -0.170*"трамп" + 0.149*"навальный" + 0.134*"митинг"'), (3, '-0.316*"теракт" + 0.314*"собор" + -0.244*"барселона" + 0.235*"исаакиевский" + 0.182*"передача" + 0.178*"рпц" + -0.168*"лондон" + 0.139*"путин" + 0.138*"меркель" + -0.132*"чуркин"'), (4, '-0.464*"евтушенко" + -0.365*"чуркин" + -0.292*"поэт" + -0.233*"евгений" + -0.175*"виталий" + 0.163*"партия" + -0.159*"умирать" + -0.147*"оон" + 0.130*"теракт" + 0.121*"великобритания"')]
Как снижение размерности влияет на матрицу близости:
corpus_lsi = lsi[corpus]
index = similarities.MatrixSimilarity(lsi[corpus])
sims = index[corpus_lsi]
sims = (sims + 1)/2.
plt.figure(figsize = (10,10))
sns.heatmap(data=sims, cmap = 'Spectral').set(xticklabels=[], yticklabels=[])
plt.title("Матрица близости")
plt.show()
Главные компоненты:
X = [0] * len(df)
Y = [0] * len(df)
for i in range(len(df)):
vec = corpus[i]
LSI_topics = (lsi[vec])
try:
for topic in LSI_topics:
if topic[0] == 0:
X[i] = topic[1]
elif topic[0] == 1:
Y[i] = topic[1]
except:
pass
vis_df = pd.DataFrame({'X': X, 'Y': Y, 'topic' : df.event})
sns.FacetGrid(vis_df, hue="topic", size = 10).map(plt.scatter, "X", "Y").add_legend()
<seaborn.axisgrid.FacetGrid at 0x14e333b38>
%%time
lda = ldamodel.LdaModel(corpus=corpus, id2word=dictionary, num_topics=20,
alpha='auto', eta='auto', iterations = 20, passes = 10)
CPU times: user 3min 7s, sys: 579 ms, total: 3min 8s Wall time: 3min 8s
lda.show_topics(5)
[(18, '0.011*"макрон" + 0.007*"франция" + 0.006*"тур" + 0.005*"ле" + 0.005*"пена" + 0.005*"победа" + 0.004*"первый" + 0.004*"выборы" + 0.004*"результат" + 0.004*"второй"'), (2, '0.019*"ракета" + 0.019*"запуск" + 0.016*"ступень" + 0.015*"компания" + 0.014*"первый" + 0.011*"повторный" + 0.010*"март" + 0.008*"спутник" + 0.007*"ракета-носитель" + 0.006*"орбита"'), (14, '0.020*"акция" + 0.020*"навальный" + 0.018*"митинг" + 0.016*"человек" + 0.014*"москва" + 0.009*"задерживать" + 0.009*"протест" + 0.008*"власть" + 0.007*"суд" + 0.007*"полиция"'), (5, '0.029*"москва" + 0.021*"человек" + 0.019*"ураган" + 0.009*"май" + 0.008*"погибший" + 0.008*"пострадать" + 0.008*"погибать" + 0.008*"столица" + 0.007*"сообщать" + 0.006*"тысяча"'), (1, '0.031*"путин" + 0.020*"президент" + 0.015*"россия" + 0.011*"вопрос" + 0.010*"меркель" + 0.008*"сша" + 0.008*"трамп" + 0.008*"встреча" + 0.008*"владимир" + 0.008*"российский"')]
import pyLDAvis.gensim as gensimvis
import pyLDAvis
vis_data = gensimvis.prepare(lda, corpus, dictionary)
pyLDAvis.display(vis_data)
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/json/encoder.py:199: DeprecationWarning: Interpreting naive datetime as local 2017-12-01 17:36:52.333998. Please add timezone info to timestamps. chunks = self.iterencode(o, _one_shot=True)
import plotly.offline as py
import plotly.graph_objs as go
py.init_notebook_mode()
def plot_difference(mdiff, title="", annotation=None):
"""
Helper function for plot difference between models
"""
annotation_html = None
if annotation is not None:
annotation_html = [
[
"+++ {}<br>--- {}".format(", ".join(int_tokens), ", ".join(diff_tokens))
for (int_tokens, diff_tokens) in row
]
for row in annotation
]
data = go.Heatmap(z=mdiff, colorscale='RdBu', text=annotation_html)
layout = go.Layout(width=500, height=500, title=title, xaxis=dict(title="topic"), yaxis=dict(title="topic"))
py.iplot(dict(data=[data], layout=layout))
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/json/encoder.py:199: DeprecationWarning: Interpreting naive datetime as local 2017-12-01 17:37:48.821297. Please add timezone info to timestamps.
mdiff, annotation = lda.diff(lda, distance='jaccard', num_words=50)
plot_difference(mdiff, title="Topic difference (one model) [jaccard distance]", annotation=annotation)
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/json/encoder.py:199: DeprecationWarning: Interpreting naive datetime as local 2017-12-01 17:38:04.726594. Please add timezone info to timestamps.
%%time
hdpmodel = HdpModel(corpus=corpus, id2word=dictionary)
CPU times: user 10 s, sys: 1.03 s, total: 11.1 s Wall time: 10.4 s
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/json/encoder.py:199: DeprecationWarning: Interpreting naive datetime as local 2017-12-01 17:38:22.016208. Please add timezone info to timestamps.
hdpmodel.show_topics(5)
[(0, '0.009*россия + 0.007*путин + 0.007*человек + 0.007*выборы + 0.007*москва + 0.007*год + 0.006*президент + 0.004*вопрос + 0.004*сказать + 0.004*время + 0.004*глава + 0.003*сообщать + 0.003*заявлять + 0.003*российский + 0.003*область + 0.003*день + 0.003*регион + 0.003*страна + 0.003*отмечать + 0.003*первый'), (1, '0.008*медведев + 0.007*россия + 0.006*москва + 0.006*фонд + 0.006*год + 0.006*человек + 0.005*партия + 0.004*выборы + 0.004*навальный + 0.003*один + 0.003*дмитрий + 0.003*вороненков + 0.003*получать + 0.003*самый + 0.003*становиться + 0.003*дар + 0.003*акция + 0.003*премьер-министр + 0.003*митинг + 0.002*президент'), (2, '0.005*москва + 0.005*теракт + 0.004*путин + 0.004*год + 0.004*трамп + 0.004*человек + 0.004*лондон + 0.004*россия + 0.003*президент + 0.003*март + 0.003*псаки + 0.003*собор + 0.003*сша + 0.003*заявлять + 0.003*сообщать + 0.002*российский + 0.002*расследование + 0.002*русский + 0.002*встреча + 0.002*связь'), (3, '0.007*расследование + 0.006*медведев + 0.004*навальный + 0.004*человек + 0.003*фбк + 0.003*теракт + 0.003*март + 0.002*фонд + 0.002*полиция + 0.002*москва + 0.002*власть + 0.002*парламент + 0.002*россия + 0.002*компания + 0.002*дмитрий + 0.002*заявлять + 0.002*один + 0.002*лондон + 0.002*сказать + 0.002*первый'), (4, '0.004*человек + 0.004*страна + 0.003*быть + 0.003*мир + 0.003*правительство + 0.003*время + 0.003*лондон + 0.003*тот + 0.003*теракт + 0.002*гражданин + 0.002*мэй + 0.002*один + 0.002*год + 0.002*сегодня + 0.002*британский + 0.002*свобода + 0.002*мы + 0.002*террорист + 0.002*новый + 0.002*великобритания')]
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/json/encoder.py:199: DeprecationWarning: Interpreting naive datetime as local 2017-12-01 17:38:38.720557. Please add timezone info to timestamps.
from sklearn.manifold import TSNE
from sklearn.decomposition import TruncatedSVD
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import Normalizer
from sklearn.feature_extraction.text import *
vectors = TfidfVectorizer().fit_transform(df.text)
X_reduced = TruncatedSVD(n_components=5, random_state=0).fit_transform(vectors)
X_embedded = TSNE(n_components=2, perplexity=5, verbose=0).fit_transform(X_reduced)
vis_df = pd.DataFrame({'X': X_embedded[:, 0], 'Y': X_embedded[:, 1], 'topic' : df.event})
sns.FacetGrid(vis_df, hue="topic", size=10).map(plt.scatter, "X", "Y").add_legend()
<seaborn.axisgrid.FacetGrid at 0x14e9425f8>